//@author A0081189X
package net.mysoc.w111j.model;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

/**
 * Storage of a user's information, including their budget and expenses.
 */
public class User {
    // Special IDs
    public static int NO_ID = -1;
    public static int UNCATEGORISED_ID = 0;
    public static int UNSPECIFIED_PAYMENT_TYPE_ID = 0;

    /** The spending budget of the user. */
    private Money budget;
    private int nextCategoryId;
    private int nextPaymentTypeId;
    private int nextExpenseId;
    /** A {@code Map} containing {@code Category} objects associated with this object. */
    private final Map<Integer, Category> categories;
    /** Similar to {@code categories}, but keyed by category name. */
    private final Map<String, Category> categoriesByName;
    /** A {@code Map} containing {@code PaymentType} objects associated with this object. */
    private final Map<Integer, PaymentType> paymentTypes;
    /** Similar to {@code paymentTypes}, but keyed by payment type name. */
    private final Map<String, PaymentType> paymentTypesByName;
    /** A {@code Map} containing {@code Expense} objects associated with this object. */
    private final Map<Integer, Expense> expenses;

    /** Default constructor. Constructs a {@code User} object with a default budget value of 0. */
    public User() {
        this(new Money());
    }

    /**
     * Constructs a {@code User} object with the specified budget value.
     *
     * @param budgetAmount the budget amount.
     */
    public User(BigDecimal budgetAmount) {
        this(new Money(budgetAmount));
    }

    /**
     * Constructs a {@code User} object with the specified budget value.
     *
     * @param budget the budget amount.
     */
    public User(Money budget) {
        this.budget = budget;

        expenses = new LinkedHashMap<>();
        nextExpenseId = 0;

        categories = new LinkedHashMap<>();
        categories.put(UNCATEGORISED_ID, new Category(UNCATEGORISED_ID, "Uncategorised"));
        nextCategoryId = 1;

        categoriesByName = new HashMap<>();
        categoriesByName.put("uncategorised", categories.get(UNCATEGORISED_ID));

        paymentTypes = new LinkedHashMap<>();
        paymentTypes.put(UNSPECIFIED_PAYMENT_TYPE_ID, new PaymentType(UNSPECIFIED_PAYMENT_TYPE_ID, "Unspecified"));
        nextPaymentTypeId = 1;

        paymentTypesByName = new HashMap<>();
        paymentTypesByName.put("unspecified", paymentTypes.get(UNSPECIFIED_PAYMENT_TYPE_ID));
    }

    /**
     * Constructs a {@code User} object. This is used only by the XStream library. Do not use under normal
     * circumstances.
     */
    public User(Money budget, int nextCategoryId, int nextExpenseId, int nextPaymentTypeId,
                List<Category> categories, List<PaymentType> paymentTypes, List<Expense> expenses) {
        this.budget = budget;
        this.nextCategoryId = nextCategoryId;
        this.nextExpenseId = nextExpenseId;
        this.nextPaymentTypeId = nextPaymentTypeId;

        this.categories = new LinkedHashMap<>();
        categoriesByName = new HashMap<>();
        for (Category c : categories) {
            this.categories.put(c.getId(), c);
            categoriesByName.put(c.getName().toLowerCase(), c);
        }

        this.paymentTypes = new LinkedHashMap<>();
        paymentTypesByName = new HashMap<>();
        for (PaymentType pt : paymentTypes) {
            this.paymentTypes.put(pt.getId(), pt);
            paymentTypesByName.put(pt.getName().toLowerCase(), pt);
        }

        this.expenses = new LinkedHashMap<>();
        for (Expense e : expenses) {
            this.expenses.put(e.getId(), e);
        }

    }

    public Money getBudget() {
        return budget;
    }

    public void setBudget(Money budget) {
        this.budget = budget;
    }

    public int getNextExpenseId() {
        return nextExpenseId;
    }

    public int getNextCategoryId() {
        return nextCategoryId;
    }

    public int getNextPaymentTypeId() {
        return nextPaymentTypeId;
    }

    public List<Category> getCategories() {
        return new LinkedList<>(categories.values());
    }

    public Category getCategoryByName(String categoryName) {
        return categoriesByName.get(categoryName.toLowerCase());
    }

    public Category getCategory(int id) {
        return categories.get(id);
    }

    public List<PaymentType> getPaymentTypes() {
        return new LinkedList<>(paymentTypes.values());
    }

    public PaymentType getPaymentTypeByName(String paymentTypeName) {
        return paymentTypesByName.get(paymentTypeName.toLowerCase());
    }

    public PaymentType getPaymentType(int id) {
        return paymentTypes.get(id);
    }

    public List<Expense> getExpenses() {
        return new LinkedList<>(expenses.values());
    }

    public Expense getExpense(int id) {
        return expenses.get(id);
    }

    /**
     * Adds or updates a {@code Category} with new information. User {@code NO_ID} as the id when adding, or the
     * existing id when updating.
     *
     * @param category the {@code Category} to be added or updated.
     * @return the added or updated {@code Category}.
     * @throws BadCategoryNameException when another category with the same name exists.
     */
    public Category updateCategory(Category category) throws BadCategoryNameException {
        if (category.getId() == NO_ID) {
            // This is a new category
            // Check if are existing categories with the same name
            if (categoriesByName.containsKey(category.getName().toLowerCase())) {
                throw new BadCategoryNameException("A category with the same name already exists.");
            }
            // add to map
            category.setId(nextCategoryId++);
            categories.put(category.getId(), category);
            categoriesByName.put(category.getName().toLowerCase(), category);
        } else {
            Category original = categories.get(category.getId());
            if (original == null) {
                // We are undoing here
                categories.put(category.getId(), category);
            } else {
                // This is an existing category
                // Check for other categories with the same name
                Category conflict = categoriesByName.get(category.getName().toLowerCase());
                if (conflict != null && conflict.getId() != category.getId()) {
                    throw new BadCategoryNameException("A category with the same name already exists.");
                }

                categoriesByName.remove(original.getName().toLowerCase());
                categoriesByName.put(category.getName().toLowerCase(), category);
                original.setName(category.getName());
                category = original;
            }
        }

        return category;
    }

    /**
     * Adds or updates a {@code PaymentType} with new information. User {@code NO_ID} as the id when adding, or the
     * existing id when updating.
     *
     * @param paymentType the {@code PaymentType} to be added or updated.
     * @return the added or updated {@code PaymentType}.
     * @throws BadPaymentTypeNameException when another payment type with the same name exists.
     */
    public PaymentType updatePaymentType(PaymentType paymentType) throws BadPaymentTypeNameException {
        if (paymentType.getId() == NO_ID) {
            // This is a new payment type
            // Check if are existing payment types with the same name
            if (paymentTypesByName.containsKey(paymentType.getName().toLowerCase())) {
                throw new BadPaymentTypeNameException("A payment type with the same name already exists.");
            }
            // add to map
            paymentType.setId(nextPaymentTypeId++);
            paymentTypes.put(paymentType.getId(), paymentType);
            paymentTypesByName.put(paymentType.getName().toLowerCase(), paymentType);
        } else {
            PaymentType original = paymentTypes.get(paymentType.getId());
            if (original == null) {
                // We are undoing here
                paymentTypes.put(paymentType.getId(), paymentType);
            } else {
                // This is an existing payment type
                // Check for other payment types with the same name
                PaymentType conflict = paymentTypesByName.get(paymentType.getName().toLowerCase());
                if (conflict != null && conflict.getId() != paymentType.getId()) {
                    throw new BadPaymentTypeNameException("A payment type with the same name already exists.");
                }

                paymentTypesByName.remove(original.getName().toLowerCase());
                paymentTypesByName.put(paymentType.getName().toLowerCase(), paymentType);
                original.setName(paymentType.getName());
                paymentType = original;
            }
        }

        return paymentType;
    }

    /**
     * Adds or updates a {@code Expense} with new information. User {@code NO_ID} as the id when adding, or the existing
     * id when updating.
     *
     * @param expense the {@code Expense} to be added or updated.
     */
    public Expense updateExpense(Expense expense) {
        if (expense.getId() == NO_ID) {
            // This is a new expense; add to map
            expense.setId(nextExpenseId++);
            expenses.put(expense.getId(), expense);
        } else {
            // This is an existing expense
            expenses.put(expense.getId(), expense);
        }

        return expense;
    }

    /**
     * Removes the category with the specified id.
     *
     * @param id the id of the category to remove.
     */
    public void removeCategory(int id) {
        Category category = categories.get(id);
        if (category != null) {
            categories.remove(id);
            categoriesByName.remove(category.getName());
        }
    }

    /**
     * Removes the paymentType with the specified id.
     *
     * @param id the id of the paymentType to remove.
     */
    public void removePaymentType(int id) {
        PaymentType paymentType = paymentTypes.get(id);
        if (paymentType != null) {
            paymentTypes.remove(id);
            paymentTypesByName.remove(paymentType.getName());
        }
    }

    /**
     * Removes the expense with the specified id.
     *
     * @param id the id of the expense to remove.
     */
    public void removeExpense(int id) {
        expenses.remove(id);
    }
}
